fix(ui): AOI map blank на open + FK select loading spinner

AOI map blank:
Pickер открывался с пустым контейнером — без zoom controls и
attribution. Leaflet вычисляет tile grid от containerSize в момент
L.map() create(); modal animation + flex-1 layout давали 0×0 на
первом кадре, тайлы не запрашивались, и map оставался blank.
setTimeout(invalidateSize, 100ms) не покрывал случай когда modal
ещё анимировал.

Решение: ResizeObserver на map div + invalidateSize при каждом
resize. Покрывает modal fade-in, dev tools toggle, window resize.
Плюс requestAnimationFrame invalidate для initial flush. Inline
style.minHeight=420/height=60vh — защита если flex не распределил
высоту.

FK select loading state:
Раньше при isLoading select disabled + показывал '…'. Теперь:
- chevron справа заменяется на animated CircleNotchIcon (animate-spin)
- aria-busy=true для screen readers
- цвет spinner ultramarain — маркирует активность

Refactor: chevron перенесён из background-image в абсолютно
позиционированный <div pointer-events-none>. Pointer-events-none
не блокирует click по нативному select.
This commit is contained in:
Zimin A.N.
2026-05-06 20:24:48 +03:00
parent b4281de41f
commit dd42626fe2
2 changed files with 63 additions and 37 deletions
@@ -3,6 +3,7 @@ import { useForm, Controller, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv' import Ajv, { type ErrorObject } from 'ajv'
import addFormats from 'ajv-formats' import addFormats from 'ajv-formats'
import { CaretDownIcon, CircleNotchIcon } from '@phosphor-icons/react'
import { import {
Alert, Alert,
Badge, Badge,
@@ -747,13 +748,10 @@ const ReferenceSelectField = ({
// scroll и type-ahead search работают OOTB. SingleSelect popup // scroll и type-ahead search работают OOTB. SingleSelect popup
// через createPortal с position:fixed не делает auto-flip и уходит // через createPortal с position:fixed не делает auto-flip и уходит
// за нижний край когда триггер близко к низу modal'а. // за нижний край когда триггер близко к низу modal'а.
// Caret chevron как inline SVG в data-uri — match'ит SingleSelect //
// CaretDown иконку (color carbon/40, weight 'regular', size ~16). // Right adornment: spinner если идёт fetch options, иначе caret.
// appearance-none убирает OS default стрелку, иначе на macOS Safari // pointer-events-none — иконка не блокирует click по нативному
// рендерится двойной chevron + native blue border. // select (он принимает все клики, включая поверх стрелки).
const caretSvg =
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23687078' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 6 8 10 12 6'/></svg>\")"
return ( return (
<Controller <Controller
control={control} control={control}
@@ -761,31 +759,41 @@ const ReferenceSelectField = ({
render={({ field }) => ( render={({ field }) => (
<div> <div>
<FieldLabel required={required}>{label}</FieldLabel> <FieldLabel required={required}>{label}</FieldLabel>
<select <div className="relative">
value={(field.value as string | undefined) ?? ''} <select
onChange={(e) => field.onChange(e.target.value || undefined)} value={(field.value as string | undefined) ?? ''}
disabled={isLoading} onChange={(e) => field.onChange(e.target.value || undefined)}
aria-invalid={Boolean(errorMsg)} disabled={isLoading}
// appearance-none + custom caret = одинаковый look на всех aria-invalid={Boolean(errorMsg)}
// платформах. py-2 (8px) match'ит TextInput высоту. aria-busy={isLoading}
// pr-10 даёт место под chevron, остальной padding как у TextInput. // appearance-none убирает OS default стрелку.
className={[ // py-2 (8px) match'ит TextInput высоту. pr-10 — место под адорнмент.
'w-full appearance-none border rounded-sm pl-3 pr-10 py-2 text-sm font-secondary', className={[
'bg-white text-black transition-colors', 'w-full appearance-none border rounded-sm pl-3 pr-10 py-2 text-sm font-secondary',
'bg-no-repeat bg-[right_0.75rem_center] bg-[length:1rem_1rem]', 'bg-white text-black transition-colors',
'focus:outline-none focus:ring-1 focus:ring-ultramarain focus:border-ultramarain', 'focus:outline-none focus:ring-1 focus:ring-ultramarain focus:border-ultramarain',
errorMsg ? 'border-mars' : 'border-regolith hover:border-carbon/40', errorMsg ? 'border-mars' : 'border-regolith hover:border-carbon/40',
isLoading ? 'cursor-wait text-carbon/60' : 'cursor-pointer', isLoading ? 'cursor-wait text-carbon/60' : 'cursor-pointer',
].join(' ')} ].join(' ')}
style={{ backgroundImage: caretSvg }} >
> <option value="">{isLoading ? '…' : '—'}</option>
<option value="">{isLoading ? '…' : '—'}</option> {options.map((opt) => (
{options.map((opt) => ( <option key={opt.id} value={opt.id}>
<option key={opt.id} value={opt.id}> {opt.label}
{opt.label} </option>
</option> ))}
))} </select>
</select> <div
className="pointer-events-none absolute inset-y-0 right-3 flex items-center"
aria-hidden="true"
>
{isLoading ? (
<CircleNotchIcon size={16} weight="bold" className="text-ultramarain animate-spin" />
) : (
<CaretDownIcon size={16} weight="regular" className="text-carbon/40" />
)}
</div>
</div>
<FieldHint>{hint}</FieldHint> <FieldHint>{hint}</FieldHint>
<FieldError>{errorMsg}</FieldError> <FieldError>{errorMsg}</FieldError>
</div> </div>
@@ -115,11 +115,25 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
mapRef.current = map mapRef.current = map
// Forces map to recompute size: modal animations cause containerSize=0 // Modal animation + flex layout = mapDiv может стартовать с 0×0 на
// первой кадр. После 100ms layout settled. // первом кадре. Leaflet вычисляет tile grid от containerSize в момент
const t = setTimeout(() => map.invalidateSize(), 100) // create() — если 0, тайлы не запрашиваются и карта остаётся пустой.
//
// Решение: ResizeObserver на контейнер, invalidateSize при каждом
// resize. Это надёжно покрывает modal fade-in, dev tools toggle,
// window resize. Дополнительно invalidateSize на следующем
// animation frame — для случая когда RO не fire'ит (initial size
// уже окончательный).
const ro = new ResizeObserver(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
ro.observe(div)
const raf = requestAnimationFrame(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
return () => { return () => {
clearTimeout(t) ro.disconnect()
cancelAnimationFrame(raf)
} }
}, [isOpen, initial]) }, [isOpen, initial])
@@ -162,7 +176,11 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
<div <div
ref={mapDivRef} ref={mapDivRef}
className="flex-1 min-h-[420px] rounded-sm border border-regolith" className="flex-1 rounded-sm border border-regolith"
// Inline height — Leaflet требует non-zero containerSize в момент
// L.map() create(). flex-1 не гарантирует высоту до layout pass;
// explicit 420px минимум защищает от blank карты.
style={{ minHeight: 420, height: '60vh' }}
aria-label={t('aoi.dialog.mapAriaLabel')} aria-label={t('aoi.dialog.mapAriaLabel')}
/> />