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 // Stub useReferencedRecords so tests don't need a QueryClient. Each test
// can override via mockReturnValue. // can override via mockReturnValue.
const mockUseReferencedRecords = vi.fn(() => ({ type MockResult = {
data: undefined, data: FlattenedRecord[] | undefined
isLoading: false, isLoading: boolean
isError: false, isError: boolean
})) }
const mockUseReferencedRecords = vi.fn<(name: string | undefined) => MockResult>(
() => ({ data: undefined, isLoading: false, isError: false }),
)
vi.mock('@/api/queries', () => ({ vi.mock('@/api/queries', () => ({
useReferencedRecords: (name: string | undefined) => mockUseReferencedRecords(name), useReferencedRecords: (name: string | undefined) => mockUseReferencedRecords(name),
})) }))
@@ -144,7 +147,6 @@ describe('SchemaDrivenForm', () => {
}, },
} as JsonSchema } as JsonSchema
const user = userEvent.setup()
renderWithI18n( renderWithI18n(
<SchemaDrivenForm <SchemaDrivenForm
schema={fkSchema} schema={fkSchema}
@@ -159,14 +161,10 @@ describe('SchemaDrivenForm', () => {
expect(mockUseReferencedRecords).toHaveBeenCalledWith('spacecraft_status') expect(mockUseReferencedRecords).toHaveBeenCalledWith('spacecraft_status')
// Hint показывает FK target. // Hint показывает FK target.
expect(screen.getByText(/Статус КА/)).toBeInTheDocument() expect(screen.getByText(/Статус КА/)).toBeInTheDocument()
// Открываем SingleSelect → видим обе локализованные опции. // Native <select> рендерит options сразу в DOM. Проверяем что обе
// SingleSelect рендерит trigger как button с placeholder "—". // опции с локализованным name присутствуют.
const triggers = screen.getAllByRole('button') expect(screen.getByRole('option', { name: 'OPERATIONAL — Действующий' })).toBeInTheDocument()
const selectTrigger = triggers.find((b) => b.textContent === '—') expect(screen.getByRole('option', { name: 'LOST — Утерян' })).toBeInTheDocument()
expect(selectTrigger).toBeDefined()
await user.click(selectTrigger!)
expect(await screen.findByText('OPERATIONAL — Действующий')).toBeInTheDocument()
expect(screen.getByText('LOST — Утерян')).toBeInTheDocument()
}) })
it('x-references fallback на TextInput когда target dict недоступен (scope-hide)', () => { 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 ( return (
<Controller <Controller
control={control} control={control}
name={`data.${fieldKey}` as `data.${string}`} name={`data.${fieldKey}` as `data.${string}`}
render={({ field }) => ( render={({ field }) => (
<SingleSelect <div>
label={label} <FieldLabel required={required}>{label}</FieldLabel>
required={required} <select
hint={hint} value={(field.value as string | undefined) ?? ''}
error={errorMsg} onChange={(e) => field.onChange(e.target.value || undefined)}
options={options} disabled={isLoading}
value={(field.value as string | undefined) ?? ''} aria-invalid={Boolean(errorMsg)}
onChange={field.onChange} className={[
placeholder={isLoading ? '…' : '—'} '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>
)} )}
/> />
) )