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