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
|
// 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}
|
|
||||||
error={errorMsg}
|
|
||||||
options={options}
|
|
||||||
value={(field.value as string | undefined) ?? ''}
|
value={(field.value as string | undefined) ?? ''}
|
||||||
onChange={field.onChange}
|
onChange={(e) => field.onChange(e.target.value || undefined)}
|
||||||
placeholder={isLoading ? '…' : '—'}
|
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