Files
mdm-ordinis/ordinis-admin-ui/src/ui/components/single-select.tsx
T
Zimin A.N. 5007e0f4eb 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.
2026-05-11 15:17:37 +03:00

94 lines
2.9 KiB
TypeScript

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>
)
}