Картка хоста: вікно по центру, список доступів не обрізається
Два дефекти на одному екрані. Модальні вікна ставали аркушем знизу під 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:
parent
452345da32
commit
ed82819419
3 changed files with 108 additions and 10 deletions
37
HISTORY.md
37
HISTORY.md
|
|
@ -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, не всередині вікна
|
||||
за екран не вилазить
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue