fix(form): FK select на native <select> — popup не вылазит за viewport

Проблема: SingleSelect popup рендерился через createPortal с
position:fixed по bbox триггера, но без auto-flip. Когда триггер
у дна modal'а (а с x-references это типичный кейс — спутник имеет
много полей), popup уходил за нижний край viewport — нижние опции
обрезались. На словарях с 20+ полями modal становится высоким и
проблема почти всегда воспроизводится.

Native <select> делегирует popup positioning браузеру: auto-flip
вверх когда мало места снизу, native scroll внутри dropdown,
type-ahead search через клавиатуру (если ввести 'OP' — прыгнет
на OPERATIONAL), mobile-friendly. Stylinglypko под @nstart/ui
(border-regolith / border-mars / focus-ultramarain) — визуально
встаёт в линейку с TextInput / DatePicker.

SingleSelect остаётся для enum-полей (короткие закрытые списки,
чаще ≤5 опций — там popup overflow не возникает).
This commit is contained in:
Zimin A.N.
2026-05-06 19:52:12 +03:00
parent d4e2e731df
commit 4fa96bffed
2 changed files with 43 additions and 24 deletions
@@ -7,11 +7,14 @@ import type { FlattenedRecord, JsonSchema } from '@/api/client'
// Stub useReferencedRecords so tests don't need a QueryClient. Each test
// can override via mockReturnValue.
const mockUseReferencedRecords = vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
}))
type MockResult = {
data: FlattenedRecord[] | undefined
isLoading: boolean
isError: boolean
}
const mockUseReferencedRecords = vi.fn<(name: string | undefined) => MockResult>(
() => ({ data: undefined, isLoading: false, isError: false }),
)
vi.mock('@/api/queries', () => ({
useReferencedRecords: (name: string | undefined) => mockUseReferencedRecords(name),
}))
@@ -144,7 +147,6 @@ describe('SchemaDrivenForm', () => {
},
} as JsonSchema
const user = userEvent.setup()
renderWithI18n(
<SchemaDrivenForm
schema={fkSchema}
@@ -159,14 +161,10 @@ describe('SchemaDrivenForm', () => {
expect(mockUseReferencedRecords).toHaveBeenCalledWith('spacecraft_status')
// Hint показывает FK target.
expect(screen.getByText(/Статус КА/)).toBeInTheDocument()
// Открываем SingleSelect → видим обе локализованные опции.
// SingleSelect рендерит trigger как button с placeholder "—".
const triggers = screen.getAllByRole('button')
const selectTrigger = triggers.find((b) => b.textContent === '—')
expect(selectTrigger).toBeDefined()
await user.click(selectTrigger!)
expect(await screen.findByText('OPERATIONAL — Действующий')).toBeInTheDocument()
expect(screen.getByText('LOST — Утерян')).toBeInTheDocument()
// Native <select> рендерит options сразу в DOM. Проверяем что обе
// опции с локализованным name присутствуют.
expect(screen.getByRole('option', { name: 'OPERATIONAL — Действующий' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'LOST — Утерян' })).toBeInTheDocument()
})
it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => {
@@ -741,21 +741,42 @@ const ReferenceSelectField = ({
)
}
// Native <select> вместо SingleSelect: для FK с длинным списком
// (50+ статусов, типов КА) и tight modal'ом native browser dropdown
// лучше — popup auto-positioning не клипается viewport'ом, native
// scroll и type-ahead search работают OOTB. SingleSelect popup
// через createPortal с position:fixed не делает auto-flip и уходит
// за нижний край когда триггер близко к низу modal'а.
return (
<Controller
control={control}
name={`data.${fieldKey}` as `data.${string}`}
render={({ field }) => (
<SingleSelect
label={label}
required={required}
hint={hint}
error={errorMsg}
options={options}
<div>
<FieldLabel required={required}>{label}</FieldLabel>
<select
value={(field.value as string | undefined) ?? ''}
onChange={field.onChange}
placeholder={isLoading ? '…' : '—'}
/>
onChange={(e) => field.onChange(e.target.value || undefined)}
disabled={isLoading}
aria-invalid={Boolean(errorMsg)}
className={[
'w-full border rounded-sm px-3 py-2 text-sm font-secondary',
'bg-white text-black transition-colors',
'focus:outline-none focus:ring-1 focus:ring-ultramarain focus:border-ultramarain',
errorMsg ? 'border-mars' : 'border-regolith hover:border-carbon/40',
isLoading ? 'cursor-wait text-carbon/60' : 'cursor-pointer',
].join(' ')}
>
<option value="">{isLoading ? '…' : '—'}</option>
{options.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
<FieldHint>{hint}</FieldHint>
<FieldError>{errorMsg}</FieldError>
</div>
)}
/>
)