fix(alerts): сделать выбор объектов доступным с клавиатуры

This commit is contained in:
Denozordec committed 2026-10-03 19:50:31 +07:00
1 parent 1deccf862c
commit f3ec6b31ce
5 files changed
+53 -71

No files matched your search

+33 -69
View File
@@ -866,8 +866,6 @@ function ServerObjectCardPicker({
selected, selected,
onToggle, onToggle,
hideLabel, hideLabel,
/** Родитель задаёт высоту и скролл (блок в шите) */
listUnconstrained,
}: { }: {
label: string label: string
rows: AlertsServerRow[] rows: AlertsServerRow[]
@@ -875,7 +873,6 @@ function ServerObjectCardPicker({
onToggle: (serverTitle: string) => void onToggle: (serverTitle: string) => void
/** Один общий заголовок снаружи (блок «шагов») */ /** Один общий заголовок снаружи (блок «шагов») */
hideLabel?: boolean hideLabel?: boolean
listUnconstrained?: boolean
}) { }) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
@@ -927,13 +924,11 @@ function ServerObjectCardPicker({
/> />
</div> </div>
<div <div
role="listbox" role="group"
aria-label={label} aria-label={label}
className={cn( className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1", "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained "overflow-visible",
? "overflow-visible"
: "max-h-[min(40vh,16rem)] overflow-y-auto",
)} )}
> >
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
@@ -948,8 +943,7 @@ function ServerObjectCardPicker({
<button <button
key={`${title}\0${s.host}`} key={`${title}\0${s.host}`}
type="button" type="button"
role="option" aria-pressed={active}
aria-selected={active}
onClick={() => onToggle(title)} onClick={() => onToggle(title)}
className={cn( className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight", "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
@@ -998,14 +992,12 @@ function GreTunnelObjectCardPicker({
selected, selected,
onToggle, onToggle,
hideLabel, hideLabel,
listUnconstrained,
}: { }: {
label: string label: string
rows: AlertsGreTunnelRow[] rows: AlertsGreTunnelRow[]
selected: string[] selected: string[]
onToggle: (targetLabel: string) => void onToggle: (targetLabel: string) => void
hideLabel?: boolean hideLabel?: boolean
listUnconstrained?: boolean
}) { }) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
@@ -1059,11 +1051,11 @@ function GreTunnelObjectCardPicker({
/> />
</div> </div>
<div <div
role="listbox" role="group"
aria-label={label} aria-label={label}
className={cn( className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1", "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto", "overflow-visible",
)} )}
> >
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
@@ -1083,8 +1075,7 @@ function GreTunnelObjectCardPicker({
<button <button
key={`${r.targetLabel}\0${r.remoteAddress}`} key={`${r.targetLabel}\0${r.remoteAddress}`}
type="button" type="button"
role="option" aria-pressed={active}
aria-selected={active}
onClick={() => onToggle(r.targetLabel)} onClick={() => onToggle(r.targetLabel)}
className={cn( className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight", "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
@@ -1150,7 +1141,6 @@ function StringOptionCardPicker({
selected, selected,
onToggle, onToggle,
hideLabel, hideLabel,
listUnconstrained,
ruleType, ruleType,
}: { }: {
label: string label: string
@@ -1158,7 +1148,6 @@ function StringOptionCardPicker({
selected: string[] selected: string[]
onToggle: (opt: string) => void onToggle: (opt: string) => void
hideLabel?: boolean hideLabel?: boolean
listUnconstrained?: boolean
ruleType: AlertType ruleType: AlertType
}) { }) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
@@ -1200,11 +1189,11 @@ function StringOptionCardPicker({
/> />
</div> </div>
<div <div
role="listbox" role="group"
aria-label={label} aria-label={label}
className={cn( className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1", "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto", "overflow-visible",
)} )}
> >
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -1217,8 +1206,7 @@ function StringOptionCardPicker({
<button <button
key={opt} key={opt}
type="button" type="button"
role="option" aria-pressed={active}
aria-selected={active}
onClick={() => onToggle(opt)} onClick={() => onToggle(opt)}
className={cn( className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight", "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
@@ -1258,7 +1246,6 @@ function ConditionOptionCardPicker({
selected, selected,
onToggle, onToggle,
hideLabel, hideLabel,
listUnconstrained,
ruleType, ruleType,
}: { }: {
label: string label: string
@@ -1266,7 +1253,6 @@ function ConditionOptionCardPicker({
selected: string[] selected: string[]
onToggle: (v: string) => void onToggle: (v: string) => void
hideLabel?: boolean hideLabel?: boolean
listUnconstrained?: boolean
ruleType: AlertType ruleType: AlertType
}) { }) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
@@ -1307,12 +1293,11 @@ function ConditionOptionCardPicker({
/> />
</div> </div>
<div <div
role="listbox" role="group"
aria-label={label} aria-label={label}
aria-multiselectable
className={cn( className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1", "flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(36vh,14rem)] overflow-y-auto", "overflow-visible",
)} )}
> >
{filtered.length === 0 ? ( {filtered.length === 0 ? (
@@ -1325,8 +1310,7 @@ function ConditionOptionCardPicker({
<button <button
key={opt} key={opt}
type="button" type="button"
role="option" aria-pressed={active}
aria-selected={active}
onClick={() => onToggle(opt)} onClick={() => onToggle(opt)}
className={cn( className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight", "rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
@@ -1561,13 +1545,6 @@ function AddRuleSheet({
...(initialForm ?? {}), ...(initialForm ?? {}),
})) }))
const nameTouchedRef = useRef(Boolean(editRuleId)) const nameTouchedRef = useRef(Boolean(editRuleId))
/** Список объектов не показываем сразу — после фокуса/клика по другому полю шита. Редактирование с выбранными объектами — сразу. */
const [objectPickerRevealed, setObjectPickerRevealed] = useState(
() => Boolean(editRuleId && (initialForm?.targets?.length ?? 0) > 0),
)
const revealObjectPicker = useCallback(() => {
setObjectPickerRevealed(true)
}, [])
/** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */ /** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */
const [objectExpanded, setObjectExpanded] = useState( const [objectExpanded, setObjectExpanded] = useState(
() => !(initialForm?.targets?.length), () => !(initialForm?.targets?.length),
@@ -1743,7 +1720,7 @@ function AddRuleSheet({
</SheetHeader> </SheetHeader>
{/* ── scrollable body ── */} {/* ── scrollable body ── */}
<fieldset disabled={saving} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 sm:px-6 py-5 flex flex-col gap-5"> <fieldset disabled={saving} className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 sm:px-6 py-5 flex flex-col gap-5 [&>div]:shrink-0">
{/* name */} {/* name */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -1755,7 +1732,7 @@ function AddRuleSheet({
size="sm" size="sm"
className="h-7 text-[11px] px-2 text-muted-foreground shrink-0" className="h-7 text-[11px] px-2 text-muted-foreground shrink-0"
onClick={() => { onClick={() => {
revealObjectPicker()
applySuggestedName() applySuggestedName()
}} }}
> >
@@ -1765,7 +1742,7 @@ function AddRuleSheet({
<Input <Input
aria-label="Название правила" placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге" aria-label="Название правила" placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге"
value={form.name} value={form.name}
onFocus={revealObjectPicker}
onChange={e => { onChange={e => {
nameTouchedRef.current = true nameTouchedRef.current = true
setForm((f) => ({ ...f, name: e.target.value })) setForm((f) => ({ ...f, name: e.target.value }))
@@ -1790,10 +1767,11 @@ function AddRuleSheet({
<button <button
key={t} key={t}
type="button" type="button"
onFocus={revealObjectPicker} aria-pressed={active}
onClick={() => { onClick={() => {
nameTouchedRef.current = false nameTouchedRef.current = false
setObjectPickerRevealed(true)
setForm((f) => ({ setForm((f) => ({
...f, ...f,
type: t, type: t,
@@ -1838,14 +1816,9 @@ function AddRuleSheet({
{allowManualTarget ? ( {allowManualTarget ? (
<div className="flex flex-col gap-1.5 min-w-0"> <div className="flex flex-col gap-1.5 min-w-0">
<FieldLabel>Объект</FieldLabel> <FieldLabel>Объект</FieldLabel>
{!objectPickerRevealed ? ( {(
<p className="text-[10px] text-muted-foreground leading-snug">
Поле ввода появится после клика или фокуса на названии правила, типе события, серьёзности,
пороге, группе или Chat ID.
</p>
) : (
<> <>
<Input <Input aria-label="Объект правила"
value={form.targets[0] ?? ""} value={form.targets[0] ?? ""}
onChange={e => { onChange={e => {
nameTouchedRef.current = false nameTouchedRef.current = false
@@ -1874,12 +1847,7 @@ function AddRuleSheet({
<p className="text-[10px] text-muted-foreground leading-snug"> <p className="text-[10px] text-muted-foreground leading-snug">
Можно выбрать несколько карточек — сработает при событии на любом из них (OR). Можно выбрать несколько карточек — сработает при событии на любом из них (OR).
</p> </p>
{!objectPickerRevealed ? ( {(
<p className="text-[10px] text-muted-foreground leading-snug">
Список объектов появится после клика или фокуса на названии правила, типе события, серьёзности,
пороге, группе или Chat ID.
</p>
) : (
<div <div
className={cn( className={cn(
"rounded-xl border border-border/80 bg-muted/10 overflow-hidden", "rounded-xl border border-border/80 bg-muted/10 overflow-hidden",
@@ -1893,11 +1861,10 @@ function AddRuleSheet({
)} )}
> >
<div className="min-h-0 overflow-hidden"> <div className="min-h-0 overflow-hidden">
<div className="p-3 max-h-[min(42vh,17rem)] overflow-y-auto overscroll-contain"> <div className="p-3">
{useGreTunnelObjectCards ? ( {useGreTunnelObjectCards ? (
<GreTunnelObjectCardPicker <GreTunnelObjectCardPicker
hideLabel hideLabel
listUnconstrained
label="Объект" label="Объект"
rows={objectGreCardRows} rows={objectGreCardRows}
selected={form.targets} selected={form.targets}
@@ -1906,7 +1873,6 @@ function AddRuleSheet({
) : useServerObjectCards ? ( ) : useServerObjectCards ? (
<ServerObjectCardPicker <ServerObjectCardPicker
hideLabel hideLabel
listUnconstrained
label="Объект" label="Объект"
rows={objectServerCardRows} rows={objectServerCardRows}
selected={form.targets} selected={form.targets}
@@ -1915,7 +1881,6 @@ function AddRuleSheet({
) : ( ) : (
<StringOptionCardPicker <StringOptionCardPicker
hideLabel hideLabel
listUnconstrained
label="Объект" label="Объект"
ruleType={form.type} ruleType={form.type}
options={targets} options={targets}
@@ -1940,7 +1905,7 @@ function AddRuleSheet({
server={objectSummaryGre ? undefined : objectSummaryServer} server={objectSummaryGre ? undefined : objectSummaryServer}
greTunnel={objectSummaryGre} greTunnel={objectSummaryGre}
onExpand={() => { onExpand={() => {
revealObjectPicker()
setObjectExpanded(true) setObjectExpanded(true)
}} }}
/> />
@@ -1972,10 +1937,9 @@ function AddRuleSheet({
"opacity-100 translate-y-0 motion-reduce:transition-none", "opacity-100 translate-y-0 motion-reduce:transition-none",
)} )}
> >
<div className="p-3 max-h-[min(40vh,16rem)] overflow-y-auto overscroll-contain"> <div className="p-3">
<ConditionOptionCardPicker <ConditionOptionCardPicker
hideLabel hideLabel
listUnconstrained
label="Условие" label="Условие"
ruleType={form.type} ruleType={form.type}
conditions={conditions} conditions={conditions}
@@ -1997,7 +1961,7 @@ function AddRuleSheet({
<Input <Input
type="number" min="0" type="number" min="0"
value={form.threshold} value={form.threshold}
onFocus={revealObjectPicker}
onChange={e => { onChange={e => {
nameTouchedRef.current = false nameTouchedRef.current = false
setForm((f) => ({ ...f, threshold: e.target.value })) setForm((f) => ({ ...f, threshold: e.target.value }))
@@ -2023,9 +1987,9 @@ function AddRuleSheet({
<button <button
key={s} key={s}
type="button" type="button"
onFocus={revealObjectPicker}
onClick={() => { onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, severity: s })) setForm((f) => ({ ...f, severity: s }))
}} }}
className={cn( className={cn(
@@ -2056,9 +2020,9 @@ function AddRuleSheet({
<button <button
key={c} key={c}
type="button" type="button"
onFocus={revealObjectPicker}
onClick={() => { onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, cooldown: c })) setForm((f) => ({ ...f, cooldown: c }))
}} }}
className={cn( className={cn(
@@ -2085,9 +2049,9 @@ function AddRuleSheet({
<button <button
key={o.label} key={o.label}
type="button" type="button"
onFocus={revealObjectPicker}
onClick={() => { onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, confirmStabilitySec: o.value })) setForm((f) => ({ ...f, confirmStabilitySec: o.value }))
}} }}
className={cn( className={cn(
@@ -2116,7 +2080,7 @@ function AddRuleSheet({
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)} )}
value={form.groupId ?? ""} value={form.groupId ?? ""}
onFocus={revealObjectPicker}
onChange={(e) => { onChange={(e) => {
const v = e.target.value const v = e.target.value
setForm((f) => ({ ...f, groupId: v ? v : null })) setForm((f) => ({ ...f, groupId: v ? v : null }))
@@ -2139,7 +2103,7 @@ function AddRuleSheet({
<FieldLabel>Chat ID — переопределить</FieldLabel> <FieldLabel>Chat ID — переопределить</FieldLabel>
<Input <Input
value={form.chatId} value={form.chatId}
onFocus={revealObjectPicker}
onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))} onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
className="font-mono text-sm" className="font-mono text-sm"
placeholder="-100xxxxxxxxxx" placeholder="-100xxxxxxxxxx"
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03 ## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.8; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.9: исправить семантику выбора объектов/условий и один scroll owner в Alerts Sheet; затем обозначить неподдерживаемое BGP-prefix событие, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует сценария остановки отправителя. Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.9; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.10: явно обозначить неподдерживаемый BGP-prefix evaluator в live UI, затем production build накопленных изменений. Далее processing recovery, компактность основной Alerts страницы, Releases/probe scheduling и приёмка 11Б/12.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.8; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.9; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.4.4.3.9 — доступный выбор и одна прокрутка формы (2026-10-03)
- Existing object/condition cards теперь native toggle buttons с aria-pressed в labelled group: UI не обещает listbox/option keyboard model без реализации arrow navigation. Type cards тоже сообщают выбранное состояние. Manual object input имеет имя. Список объектов доступен сразу, не требует фокуса на постороннем поле.
- Удалены listUnconstrained API и все guessed max-h в локальных pickers; один fieldset с min-h-0/flex-1 owns scroll. Header/footer закреплены, блоки тела не сжимаются. Installed ReUI primitives/theme не менялись; Frame checklist применён.
- Typecheck/lint, 21 browser scenarios picker/rules/preview/queue прошли. 320/1440 keyboard Space/pressed, один scroll owner, доступный footer и отсутствие overflow проверены; screenshot320 просмотрен. Первоначальный прогон нашёл синтаксическую ошибку удаления старого conditional и скрытый initial object picker; обе причины исправлены до успешного повторного прогона.
- Это локальная приёмка формы, не всей Alerts страницы; BGP-prefix всё ещё не имеет evaluator, основной экран требует компактности и полная внешняя приёмка отсутствует.
- Следующий точный шаг 11Б.4.4.3.10: явно обозначить неподдерживаемый BGP-prefix evaluator в live UI, затем production build накопленных изменений. Далее processing recovery, компактность основной Alerts страницы, Releases/probe scheduling и приёмка 11Б/12.
## Блок 11Б.4.4.3.8 — честные backend счётчики каталогов (2026-10-03) ## Блок 11Б.4.4.3.8 — честные backend счётчики каталогов (2026-10-03)
- WireGuard/IPsec/VXLAN/BGP/certificates count берётся только из полного результата с явным failures:[]; containers — из нового typed state с available containers на всех устройствах. Legacy BGP/container list с swallowed errors не используется для count. Known empty даёт 0; timeout/throw/partial/malformed count даёт undefined, который HTTP не выдаёт как числовое значение. - WireGuard/IPsec/VXLAN/BGP/certificates count берётся только из полного результата с явным failures:[]; containers — из нового typed state с available containers на всех устройствах. Legacy BGP/container list с swallowed errors не используется для count. Known empty даёт 0; timeout/throw/partial/malformed count даёт undefined, который HTTP не выдаёт как числовое значение.
+4
View File
@@ -312,3 +312,7 @@ Dashboard per-request permissions принят локально: mapper parity13
### Продолжение 2026-10-03 — 11Б.4.4.3.8 ### Продолжение 2026-10-03 — 11Б.4.4.3.8
Backend sidebar optional counts не выдают timeout/partial/error как0, используют complete typed catalogs; helper2/HTTP/build/containers/live-maps/lint/browser7 прошли. Сетевой опрос после deadline не отменяется; PG/RouterOS внешняя приёмка нужна. Следующий блок 11Б.4.4.3.9: исправить семантику выбора объектов/условий и один scroll owner в Alerts Sheet; затем обозначить неподдерживаемое BGP-prefix событие, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует сценария остановки отправителя. Backend sidebar optional counts не выдают timeout/partial/error как0, используют complete typed catalogs; helper2/HTTP/build/containers/live-maps/lint/browser7 прошли. Сетевой опрос после deadline не отменяется; PG/RouterOS внешняя приёмка нужна. Следующий блок 11Б.4.4.3.9: исправить семантику выбора объектов/условий и один scroll owner в Alerts Sheet; затем обозначить неподдерживаемое BGP-prefix событие, Releases/probe scheduling и приёмка 11Б/12. Processing recovery требует сценария остановки отправителя.
### Продолжение 2026-10-03 — 11Б.4.4.3.9
Picker keyboard semantics/one parent scroll/initial objects accepted locally; browser21/typecheck/lint/ReUI audit passed, 320px screenshot reviewed. Общий Alerts экран не принят. Следующий блок 11Б.4.4.3.10: явно обозначить неподдерживаемый BGP-prefix evaluator в live UI, затем production build накопленных изменений. Далее processing recovery, компактность основной Alerts страницы, Releases/probe scheduling и приёмка 11Б/12.
+6
View File
@@ -0,0 +1,6 @@
import {test,expect} from "@playwright/test"
import {emptyAlerts,emptyOutbox} from "./fixtures/alerts"
for(const width of [320,1440])test("Alerts picker native keyboard selection and one sheet scroll owner/"+width,async({page})=>{
await page.setViewportSize({width,height:900});const servers=Array.from({length:24},(_,i)=>({name:"Fixture server "+i,host:"192.0.2."+(i+1),site:"TEST",country:"RU",type:"exit-node"}))
await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,meta:{...emptyAlerts.meta,servers:servers.map(row=>row.name),serversDetail:servers}}:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await page.getByRole("button",{name:"Добавить правило",exact:true}).first().click();const dialog=page.getByRole("dialog");const objects=dialog.getByRole("group",{name:"Объект",exact:true});const card=objects.getByRole("button").filter({hasText:"Fixture server 0"});await card.focus();await page.keyboard.press("Space");await expect(card).toHaveAttribute("aria-pressed","true");const conditions=dialog.getByRole("group",{name:"Условие",exact:true});const condition=conditions.getByRole("button").first();await condition.focus();await page.keyboard.press("Space");await expect(condition).toHaveAttribute("aria-pressed","true");await expect(dialog.getByRole("listbox")).toHaveCount(0);const scrollOwners=await dialog.locator("*").evaluateAll(nodes=>nodes.filter(node=>{const style=getComputedStyle(node);return ["auto","scroll"].includes(style.overflowY)&&node.scrollHeight>node.clientHeight+1}).length);expect(scrollOwners).toBe(1);const save=dialog.getByRole("button",{name:"Сохранить правило",exact:true});await save.scrollIntoViewIfNeeded();await expect(save).toBeInViewport();const bounds=await dialog.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(width);await dialog.screenshot({path:"test-results/alerts-pickers-"+width+".png"})
})