Картка хоста: вікно по центру, список доступів не обрізається
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Два дефекти на одному екрані.

Модальні вікна ставали аркушем знизу під 768 px. Вікно браузера на
740 px — не телефон, а десктоп у півекрана, і аркуш там притискався до
низу й обрізався. Поріг знижено до 640 px.

Випадний список малювався absolute усередині вікна з власною
прокруткою, і та його обрізала: видно було стільки пунктів, скільки
лишалось місця до краю форми. Тепер портал у body з fixed-координатами
від поля, з розгортанням угору, коли знизу тісно. Портал, а не більший
max-height: обрізає будь-який предок із прокруткою.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 17:14:50 +03:00
parent 452345da32
commit ed82819419
3 changed files with 108 additions and 10 deletions

View file

@ -2875,3 +2875,40 @@ NAT цілком може слухати SSH на 2222.
порт 2222 у доступі → 2222
без доступу → лишається підказка профілю
```
## Картка хоста виглядала зламаною
Два різні дефекти, що збіглися на одному екрані.
### Вікно було притиснуте до низу
Модальні вікна робились аркушем знизу під `md` (768 px) і центрувались
вище. Але вікно браузера на 740 px — це не телефон, а десктоп у
півекрана: аркуш там виглядав зламаним, бо притискався до низу й
обрізався зверху вмістом.
Поріг знижено до `sm` (640 px). Аркуш лишився там, де він доречний — на
телефоні.
### Випадний список обрізався формою
Список у полі пошуку доступів малювався `absolute` всередині вікна, а
вікно має власну прокрутку (`overflow-y-auto`). Такий контейнер обрізає
все, що виходить за його межі: видно було рівно стільки пунктів, скільки
лишалось місця до краю форми, і останній різався навпіл.
Тепер список малюється порталом у `body` з `position: fixed`, а
координати рахуються від поля. Він їде за полем при прокрутці й
розгортається вгору, коли знизу місця менше, ніж зверху.
Портал, а не збільшення `max-height`: обрізання дає будь-який предок із
прокруткою, і наступного разу це була б інша форма.
### Перевірено у вікні 740×741 — тому самому, де це помітили
```
вікно форми top 224, bottom 517 — по центру, не аркуш
список top 441, bottom 539 — виходить за межі форми
й не обрізається; у body, не всередині вікна
за екран не вилазить
```

View file

@ -1,4 +1,5 @@
import { useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { inputClass } from './ui'
export type PickerItem = { id: string; label: string; hint?: string }
@ -32,6 +33,47 @@ export function Picker({
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
const field = useRef<HTMLInputElement>(null)
const [rect, setRect] = useState<{ left: number; top: number; width: number; up: boolean } | null>(
null,
)
// Список малюється порталом у body, а не всередині поля.
//
// Форма хоста живе в модальному вікні з власною прокруткою, а
// absolute-список обрізається межами такого контейнера: видно рівно
// стільки пунктів, скільки лишилось місця до краю вікна, і останній
// ріжеться навпіл. Портал виносить його з-під обрізання, а координати
// рахуються від поля.
const place = useCallback(() => {
const el = field.current
if (!el) return
const r = el.getBoundingClientRect()
const below = window.innerHeight - r.bottom
// Не влазить знизу, а зверху місця більше — розгортаємо вгору.
const up = below < 240 && r.top > below
setRect({ left: r.left, top: up ? r.top : r.bottom, width: r.width, up })
}, [])
useLayoutEffect(() => {
if (!open) return
place()
// Прокрутка сторінки чи самого вікна зсуває поле — список має їхати
// за ним, інакше зависне посеред екрана.
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, place])
// Кількість знайденого змінює висоту списку — перерахунок потрібен і
// на кожну зміну запиту, інакше список, розгорнутий угору, від'їде
// від поля.
useEffect(() => {
if (open) place()
}, [open, place, query, value.length])
const byID = useMemo(() => new Map(items.map((i) => [i.id, i])), [items])
@ -77,6 +119,7 @@ export function Picker({
)}
<input
ref={field}
className={inputClass}
placeholder={placeholder}
value={query}
@ -103,8 +146,21 @@ export function Picker({
}}
/>
{open && !disabled && (
<ul className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-slate-700 bg-slate-900 shadow-xl">
{open &&
!disabled &&
rect &&
createPortal(
<ul
className="fixed z-[60] max-h-56 overflow-y-auto rounded border border-slate-700
bg-slate-900 shadow-xl"
style={{
left: rect.left,
width: rect.width,
...(rect.up
? { bottom: window.innerHeight - rect.top + 4 }
: { top: rect.top + 4 }),
}}
>
{matches.map((i) => (
<li key={i.id}>
<button
@ -117,11 +173,12 @@ export function Picker({
</button>
</li>
))}
{matches.length === 0 && (
<li className="px-3 py-2 text-xs text-slate-600">{empty}</li>
)}
</ul>
)}
{matches.length === 0 && (
<li className="px-3 py-2 text-xs text-slate-600">{empty}</li>
)}
</ul>,
document.body,
)}
</div>
)
}

View file

@ -329,7 +329,11 @@ export function Modal({
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4"
// Аркуш знизу — для телефона; на будь-якому ширшому екрані вікно
// по центру. Поріг sm, а не md: вікно браузера на 740 px — це вже
// не телефон, а десктоп у півекрана, і аркуш там виглядає
// зламаним, бо притиснутий до низу й обрізаний.
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 sm:items-center sm:p-4"
// Клік повз панель закриває. Перевіряємо, що натискання ПОЧАЛОСЬ
// поза панеллю: інакше виділення тексту, доведене мишею за межі
// вікна, закривало б форму разом із набраним.
@ -343,7 +347,7 @@ export function Modal({
aria-modal="true"
aria-label={title}
className={`max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
bg-slate-900 md:rounded-xl ${wide ? 'md:max-w-3xl' : 'md:max-w-lg'}`}
bg-slate-900 sm:rounded-xl ${wide ? 'sm:max-w-3xl' : 'sm:max-w-lg'}`}
>
<div className="sticky top-0 z-10 flex items-center border-b border-slate-800 bg-slate-900 px-4 py-3">
<h2 className="text-sm font-semibold text-slate-100">{title}</h2>