5007e0f4eb
Все 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.
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
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>
|
|
)
|
|
}
|