Merge branch 'feat/stage-3.9-drop-nstart-ui' into 'main'

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

See merge request 2-6/2-6-4/terravault/ordinis!52
This commit is contained in:
Александр Зимин
2026-05-11 12:17:56 +00:00
27 changed files with 916 additions and 191 deletions
-1
View File
@@ -12,7 +12,6 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@nstart/ui": "^0.1.3",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
-60
View File
@@ -11,9 +11,6 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.9.1 specifier: ^3.9.1
version: 3.10.0(react-hook-form@7.75.0(react@19.2.5)) 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': '@phosphor-icons/react':
specifier: ^2.1.10 specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) 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==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} 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': '@octokit/auth-token@6.0.0':
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
engines: {node: '>= 20'} engines: {node: '>= 20'}
@@ -2055,9 +2043,6 @@ packages:
duplexer2@0.1.4: duplexer2@0.1.4:
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} 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: electron-to-chromium@1.5.349:
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
@@ -2321,9 +2306,6 @@ packages:
highlight.js@10.7.3: highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
hook-std@4.0.0: hook-std@4.0.0:
resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2647,15 +2629,6 @@ packages:
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} 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: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -3742,9 +3715,6 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots: snapshots:
'@adobe/css-tools@4.4.4': {} '@adobe/css-tools@4.4.4': {}
@@ -4127,17 +4097,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 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/auth-token@6.0.0': {}
'@octokit/core@7.0.6': '@octokit/core@7.0.6':
@@ -5481,11 +5440,6 @@ snapshots:
dependencies: dependencies:
readable-stream: 2.3.8 readable-stream: 2.3.8
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.349: {} electron-to-chromium@1.5.349: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -5808,8 +5762,6 @@ snapshots:
highlight.js@10.7.3: {} highlight.js@10.7.3: {}
hls.js@1.6.16: {}
hook-std@4.0.0: {} hook-std@4.0.0: {}
hosted-git-info@7.0.2: hosted-git-info@7.0.2:
@@ -6076,14 +6028,6 @@ snapshots:
lodash@4.18.1: {} 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: {} loupe@3.2.1: {}
lowercase-keys@3.0.0: {} lowercase-keys@3.0.0: {}
@@ -6976,7 +6920,3 @@ snapshots:
yoctocolors@2.1.2: {} yoctocolors@2.1.2: {}
zod@3.25.76: {} zod@3.25.76: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0
@@ -66,8 +66,8 @@ describe('SchemaDrivenForm', () => {
onCancel={() => {}} onCancel={() => {}}
/>, />,
) )
// @nstart/ui Tabs использует role="button" не "tab". // Radix Tabs (Stage 3.9) использует role="tab" — semantically correct.
await user.click(screen.getByRole('button', { name: /Дополнительно/i })) await user.click(screen.getByRole('tab', { name: /Дополнительно/i }))
// description (optional) теперь виден. i18n field.description=Описание. // description (optional) теперь виден. i18n field.description=Описание.
expect(screen.getByText('Описание')).toBeInTheDocument() expect(screen.getByText('Описание')).toBeInTheDocument()
}) })
@@ -775,7 +775,7 @@ const ReferenceSelectField = ({
return records return records
.map((r) => buildOption(r, refField, defaultLocale)) .map((r) => buildOption(r, refField, defaultLocale))
.filter((o): o is SelectOption => o !== null) .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]) }, [records, refField, defaultLocale])
const hint = schema.description ?? `FK на ${ref}` 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', 'w-full appearance-none border rounded-sm pl-3 pr-10 py-2 text-body font-display',
'bg-white text-black transition-colors', 'bg-white text-black transition-colors',
'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent', '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', isLoading ? 'cursor-wait text-mute' : 'cursor-pointer',
].join(' ')} ].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', 'px-3 py-1.5 text-cell font-display rounded-sm border transition-colors',
active active
? 'border-accent bg-accent text-white' ? '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(' ')} ].join(' ')}
> >
{label} {label}
@@ -192,7 +192,7 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
type="button" type="button"
onClick={handleClear} onClick={handleClear}
disabled={!shape} 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')} {t('aoi.dialog.clear')}
</button> </button>
@@ -61,7 +61,7 @@ const NeighborCard = ({
<div className={`flex items-baseline gap-2 ${side === 'left' ? 'justify-end' : ''}`}> <div className={`flex items-baseline gap-2 ${side === 'left' ? 'justify-end' : ''}`}>
<span <span
className={`inline-block size-1.5 rounded-full shrink-0 ${ 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" aria-hidden="true"
/> />
@@ -67,9 +67,9 @@ export const RecordHistoryDrawer = ({ open, onClose, dictionaryName, businessKey
isLatest && status === 'active' isLatest && status === 'active'
? 'bg-accent border-accent' ? 'bg-accent border-accent'
: status === 'future' : status === 'future'
? 'bg-orbit border-horizon' ? 'bg-warn border-horizon'
: status === 'expired' : status === 'expired'
? 'bg-white border-carbon/40' ? 'bg-white border-ink/40'
: 'bg-aurora border-aurora' : 'bg-aurora border-aurora'
}`} }`}
/> />
@@ -306,7 +306,7 @@ function TimelineTrack({
{tooltipText && hover !== null && ( {tooltipText && hover !== null && (
<div <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%)' }} style={{ left: timeToX(hover), transform: 'translate(-50%, -100%)' }}
> >
{tooltipText} {tooltipText}
+5 -7
View File
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router' 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 { AuthProvider } from 'react-oidc-context'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import { oidcAuthConfig } from '@/auth/oidcConfig' import { oidcAuthConfig } from '@/auth/oidcConfig'
@@ -31,16 +31,14 @@ createRoot(document.getElementById('root')!).render(
<AuthProvider {...oidcAuthConfig}> <AuthProvider {...oidcAuthConfig}>
<TokenSync /> <TokenSync />
<ThemeProvider> <ThemeProvider>
<NStartUiProvider> <TooltipProvider delayDuration={150}>
<RequireAuth> <RequireAuth>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}> <RouterProvider router={router} />
<RouterProvider router={router} /> <Toaster />
<Toaster />
</DatePickerProvider>
</QueryClientProvider> </QueryClientProvider>
</RequireAuth> </RequireAuth>
</NStartUiProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
</AuthProvider> </AuthProvider>
</StrictMode>, </StrictMode>,
+1 -1
View File
@@ -309,7 +309,7 @@ function ActiveFilterChips({ filters, onRemove }: ActiveFilterChipsProps) {
onClick={() => onRemove(f.key)} onClick={() => onRemove(f.key)}
className={[ className={[
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm border text-cell', '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', 'transition-colors group',
].join(' ')} ].join(' ')}
title={t('audit.filter.removeChip')} 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 { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import axios from 'axios' import axios from 'axios'
@@ -322,18 +322,12 @@ function DictionaryDetail() {
const someVisibleSelected = const someVisibleSelected =
!allVisibleSelected && visibleKeys.some((k) => selection.has(k)) !allVisibleSelected && visibleKeys.some((k) => selection.has(k))
// Native checkbox indeterminate state — sync через useEffect, не callback ref. // Stage 3.9: Radix Checkbox принимает {@code checked="indeterminate"} как
// Callback ref срабатывает только на mount/unmount → state становится stale // tri-state значение declaratively. Старый useEffect+ref hack (для нативного
// когда user toggle'ает selection per-row. useEffect re-runs на изменение // input.indeterminate JS-only prop) больше не нужен.
// someVisibleSelected. См. design handoff review #17 + Checkbox в @nstart/ui: const selectAllChecked: boolean | 'indeterminate' = someVisibleSelected
// ref forwarded к нативному <input>, но `indeterminate` это JS DOM-only prop ? 'indeterminate'
// (не HTML attribute), нужен manual sync. : allVisibleSelected
const selectAllRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = someVisibleSelected
}
}, [someVisibleSelected])
const toggleSelect = (key: string) => { const toggleSelect = (key: string) => {
setSelection((prev) => { 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', 'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active active
? 'border-accent bg-accent/8 text-accent' ? '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(' ')} ].join(' ')}
> >
<span <span
@@ -773,8 +767,7 @@ function DictionaryDetail() {
<TableHeaderCell> <TableHeaderCell>
<Checkbox <Checkbox
aria-label={t('dict.bulk.selectAll')} aria-label={t('dict.bulk.selectAll')}
checked={allVisibleSelected} checked={selectAllChecked}
ref={selectAllRef}
onChange={toggleSelectAll} onChange={toggleSelectAll}
/> />
</TableHeaderCell> </TableHeaderCell>
@@ -834,7 +827,7 @@ function DictionaryDetail() {
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell', 'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk c.isFk
? 'bg-accent/8 text-accent' ? 'bg-accent/8 text-accent'
: 'bg-carbon/8 text-ink', : 'bg-ink/8 text-ink',
].join(' ')} ].join(' ')}
> >
{text} {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', 'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
active active
? 'border-accent bg-accent/8 text-accent' ? '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(' ')} ].join(' ')}
> >
{s} {s}
@@ -452,7 +452,7 @@ function RotateRevealModal({
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}> <Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.rotatedWarning')} {t('webhooks.secret.rotatedWarning')}
</Alert> </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> <code className="text-mono break-all">{secret}</code>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -367,7 +367,7 @@ function SecretRevealModal({
<Alert variant="warning" title={t('webhooks.secret.warningTitle')}> <Alert variant="warning" title={t('webhooks.secret.warningTitle')}>
{t('webhooks.secret.warning')} {t('webhooks.secret.warning')}
</Alert> </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> <code className="text-mono break-all">{revealed.secret}</code>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
+7 -22
View File
@@ -1,14 +1,9 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css'; @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). /* Google Fonts Inter / JetBrains Mono / Tektur (handoff spec).
* @nstart fonts.css дублируется по части семейств overlap безвреден, * Stage 3.9: @nstart/ui удалён, fonts/theme imports + @source directive
* на финальной фазе миграции (когда удалим @nstart/ui) останется только это. */ * убраны. 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'); @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 === /* === Dark mode trigger ===
@@ -146,21 +141,11 @@
--font-mono: 'JetBrains Mono', 'SF Mono', monospace; --font-mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-display: 'Tektur', 'Inter', sans-serif; --font-display: 'Tektur', 'Inter', sans-serif;
/* Font scale. /* Font scale НЕ переопределяем Tailwind defaults. Все наши места
* * используют семантические @utility ниже (text-body, text-cell, text-mono,
* Наши собственные классы семантические @utility ниже (text-body, * text-title-*, text-cap). text-sm/text-xs/text-2xs не используются нигде
* text-cell, text-mono, text-title-*, text-cap). Каждая роль = место в UI. * после Stage 3.9 @nstart/ui (который требовал --text-sm override)
* * удалён. */
* --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;
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
@@ -4,16 +4,52 @@ import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** /**
* Checkbox Radix primitive с indeterminate support через {@code checked='indeterminate'}. * Checkbox Radix primitive с nstart-compat label / description / onChange API.
* Решает старый bug @nstart/ui где indeterminate не sync'ался через ref. *
* <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< export const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> CheckboxProps
>(function Checkbox({ className, ...props }, ref) { >(function Checkbox(
return ( { 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 <CheckboxPrimitive.Root
ref={ref} ref={ref}
id={id}
onCheckedChange={handleCheckedChange}
className={cn( className={cn(
'peer h-4 w-4 shrink-0 rounded-[3px] border border-line bg-surface', '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', '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.Indicator>
</CheckboxPrimitive.Root> </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 форм. * FormActions фиксированная row кнопок [Cancel | Submit] в footer форм.
* Reverse на mobile primary action на bottom (proper UX). * 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 ( return (
<div <div
className={cn( 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, className,
)} )}
{...props} {...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' size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
children?: React.ReactNode children?: React.ReactNode
hideClose?: boolean hideClose?: boolean
/** Дополнительный className на DialogContent panel (nstart-compat). */
panelClassName?: string
/** Дополнительный className на body wrapper (nstart-compat). */
bodyClassName?: string
} }
export function Modal({ export function Modal({
@@ -54,6 +58,8 @@ export function Modal({
maxWidth, maxWidth,
size, size,
hideClose, hideClose,
panelClassName,
bodyClassName,
children, children,
}: ModalProps) { }: ModalProps) {
const resolved = size ?? (maxWidth ? SIZE_MAP[maxWidth] : undefined) ?? 'md' const resolved = size ?? (maxWidth ? SIZE_MAP[maxWidth] : undefined) ?? 'md'
@@ -64,14 +70,14 @@ export function Modal({
if (!open) onClose() if (!open) onClose()
}} }}
> >
<DialogContent size={resolved} hideClose={hideClose}> <DialogContent size={resolved} hideClose={hideClose} className={panelClassName}>
{(title || description) && ( {(title || description) && (
<DialogHeader> <DialogHeader>
{title && <DialogTitle>{title}</DialogTitle>} {title && <DialogTitle>{title}</DialogTitle>}
{description && <DialogDescription>{description}</DialogDescription>} {description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader> </DialogHeader>
)} )}
{children} {bodyClassName ? <div className={bodyClassName}>{children}</div> : children}
</DialogContent> </DialogContent>
</Dialog> </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 id: string
label: React.ReactNode label: React.ReactNode
disabled?: boolean disabled?: boolean
/** Right-aligned content в tab (badge, indicator). */ /** Right-aligned content в tab (badge, indicator). Alias `trailing`. */
trailing?: React.ReactNode trailing?: React.ReactNode
/** nstart-compat alias для `trailing`. Если оба заданы — count wins. */
count?: React.ReactNode
} }
export type TabsSimpleProps = { 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}> <RadixTabsTrigger key={item.id} value={item.id} disabled={item.disabled}>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
{item.label} {item.label}
{item.trailing} {item.count ?? item.trailing}
</span> </span>
</RadixTabsTrigger> </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): * <p>Stage 3.9: @nstart/ui удалён. Все экспорты резолвят к локальным
* <ul> * shadcn primitives в `./components/`. Имена сохранены в nstart-compat форме
* <li>{@code @nstart/ui} остаётся подключённым все его компоненты * чтобы существующий код работал без массового рефактора imports.
* 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>Текущая семантика: import {X} from '@/ui' Х берётся приоритетно из * <p>Группы:
* наших shadcn primitives (overrides ниже), иначе passthrough из @nstart/ui. * <ol>
* Этот файл single source of truth для UI imports. * <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 === // === Atomic ===
// Дальше будут 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 и т.д.).
export { Button, buttonVariants, type ButtonProps } from './components/button' export { Button, buttonVariants, type ButtonProps } from './components/button'
export { Badge, type BadgeProps } from './components/badge' export { Badge, type BadgeProps } from './components/badge'
export { Alert, type AlertProps } from './components/alert' export { Alert, type AlertProps } from './components/alert'
// TextArea override — shadcn с handoff tokens. Phase 3 migration step export { IconButton, type IconButtonProps } from './components/icon-button'
// (Stage 3.8a). API совместим с @nstart/ui TextArea (label/hint/error). export { Input, type InputProps } from './components/input'
export { TextArea, type TextAreaProps } from './components/textarea' export { Label } from './components/label'
// PageHeader override — @nstart/ui рендерил h1 в Tektur 36px/700 (text-4xl export { Separator } from './components/separator'
// uppercase tracking-tight font-primary), что не соответствует handoff export { Skeleton } from './components/skeleton'
// page title 17/600 Inter. Наша версия использует text-title-lg utility. export { Switch } from './components/switch'
export { PageHeader } from './components/page-header'
// === Handoff design system primitives — новые компоненты без nstart-аналога === // === Handoff design primitives ===
export { Cap, type CapProps } from './components/cap' export { Cap, type CapProps } from './components/cap'
export { export {
ScopeDot, ScopeDot,
@@ -52,23 +41,72 @@ export {
type DataScope as ScopeKind, type DataScope as ScopeKind,
} from './components/scope' } from './components/scope'
// === shadcn primitives для НОВОГО кода (Phase 3) === // === Form fields ===
// Эти именованные exports НЕ конфликтуют с nstart (имена не пересекаются). export { TextInput, type TextInputProps } from './components/text-input'
// Используются в редизайне catalog/editor/drawer per handoff README. export { TextArea, type TextAreaProps } from './components/textarea'
export { IconButton as ShadcnIconButton, type IconButtonProps } from './components/icon-button' export { Checkbox } from './components/checkbox'
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'
export { 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, CardHeader,
CardTitle, CardTitle,
CardDescription, CardDescription,
CardContent, CardContent,
CardFooter, CardFooter,
} from './components/card' } from './components/card'
export { Separator } from './components/separator' export { Panel, type PanelProps } from './components/panel'
export { Skeleton } from './components/skeleton' 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 { export {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -106,13 +144,6 @@ export {
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from './components/tooltip' } from './components/tooltip'
// Tabs из shadcn — composition API.
export {
Tabs as ShadcnTabsRoot,
TabsList as ShadcnTabsList,
TabsTrigger as ShadcnTabsTrigger,
TabsContent as ShadcnTabsContent,
} from './components/tabs'
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -129,9 +160,13 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} from './components/dropdown-menu' } from './components/dropdown-menu'
export { Switch as ShadcnSwitch } from './components/switch'
// Toast notifications через sonner (rich, accessible, themable). // === Switches ===
// Mount <Toaster /> один раз в main.tsx; call toast.success/error/etc. export {
// в callbacks / effects. LanguageSwitch,
type LanguageOption,
type LanguageSwitchProps,
} from './components/language-switch'
// === Toast (sonner) ===
export { Toaster, toast } from './components/toaster' export { Toaster, toast } from './components/toaster'