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:
@@ -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",
|
||||
|
||||
Generated
-60
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 в <label> с описанием.</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>Сейчас простой <input type="date"> — браузерный 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>
|
||||
)
|
||||
}
|
||||
@@ -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 <select> 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). Применяется на
|
||||
* <td/th> через 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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user