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:
@@ -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>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user