Okno potwierdzenia zwarte, ostrzezenie tylko tam gdzie cos przepada

UKLAD
Okno korzystalo z komponentu `Modal`, ktory jest zbudowany pod FORMULARZE:
naglowek oddzielony kreska, przycisk zamkniecia w rogu, przewijana tresc
i stopka oddzielona druga kreska. Przy imporcie kontaktow to sluszne, ale przy
pytaniu na dwa zdania te same elementy rozdymaly okno: dwie linie podzialu
i krzyzyk zajmowaly wiecej miejsca niz sama tresc.

Okno potwierdzenia jest krotkie z natury, wiec dostaje uklad ciagly: ikona
ostrzezenia jako okragla plakietka OBOK tytulu zamiast osobnego paska, przyciski
tuz pod trescia. Krzyzyka nie ma, bo bylby trzecim sposobem powiedzenia "nie",
obok przycisku "Anuluj", klawisza Escape i klikniecia w tlo. Naklatka, Escape
i blokada przewijania tla zostaja takie same jak w `Modal`: powtarzamy
zachowanie, nie uklad.

OSTRZEZENIE PRZESTAJE BYC WSZEDZIE
Trzy operacje mialy `destructive`, choc niczego nie niszcza:

- zatrzymanie nagrywania: plik zostaje, nic nie przepada. Nieodwracalne jest
  wylacznie to, ze nagranej chwili nie da sie powtorzyc,
- anulowanie zamowienia: zamowienie jest dokumentem finansowym i ZOSTAJE
  w dzienniku, co mowila zreszta sama tresc komunikatu,
- przywrocenie grafiki domyslnej: plik da sie wgrac z powrotem, a "przywroc
  domyslna" to cofniecie, nie zniszczenie.

Przy zatrzymaniu nagrywania tresc powtarzala przy tym ostrzezenie wlasnymi
slowami, wiec to samo bylo napisane dwa razy.

Ostrzezenie uzyte wszedzie przestaje cokolwiek znaczyc tam, gdzie strata jest
prawdziwa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:35:52 +02:00
parent 1599c7b0c1
commit e7288c9f7e
4 changed files with 99 additions and 44 deletions
+82 -29
View File
@@ -1,6 +1,5 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { Button } from './ui'
/**
@@ -76,41 +75,95 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
<ConfirmContext.Provider value={api}>
{children}
{pending && (
<Modal
title={pending.title}
onClose={() => settle(false)}
footer={
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => settle(false)}>
{pending.cancelLabel ?? 'Anuluj'}
{pending && <ConfirmDialog options={pending} onSettle={settle} />}
</ConfirmContext.Provider>
)
}
/**
* Samo okno pytania.
*
* NIE UZYWA `Modal`, I TO JEST SWIADOME
* ------------------------------------------------------------------------
* `Modal` jest zbudowany pod FORMULARZE: ma naglowek oddzielony kreska,
* przycisk zamkniecia w rogu, przewijana tresc i stopke oddzielona druga
* kreska. Przy imporcie kontaktow albo edycji banera to sluszne, ale przy
* pytaniu na dwa zdania te same elementy rozdymaja okno: dwie linie
* podzialu i krzyzyk zajmuja wtedy wiecej miejsca niz sama tresc.
*
* Okno potwierdzenia jest krotkie z natury, wiec dostaje uklad ciagly, bez
* kresek, a wyjscie prowadzi przez przycisk "Anuluj". Krzyzyk bylby trzecim
* sposobem powiedzenia "nie", obok przycisku, Escape i klikniecia w tlo.
*
* Naklatka, Escape i blokada przewijania tla sa te same co w `Modal`:
* powtarzamy zachowanie, a nie uklad.
*/
function ConfirmDialog({
options,
onSettle,
}: {
options: ConfirmOptions
onSettle: (value: boolean) => void
}) {
useEffect(() => {
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') onSettle(false)
}
document.addEventListener('keydown', onKey)
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previous
}
}, [onSettle])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-900/40 p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onSettle(false)
}}
>
<div className="w-full rounded-lg border border-slate-100 bg-white p-5 shadow-xl sm:max-w-md sm:p-6">
<div className="flex items-start gap-3">
{options.destructive && (
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-alarm-50">
<Icon name="alert-triangle" size={18} className="text-alarm-600" />
</span>
)}
<div className="min-w-0">
<h2 id="confirm-title" className="text-base font-semibold text-slate-900">
{options.title}
</h2>
{options.message && (
<div className="mt-1.5 text-sm leading-relaxed text-slate-600">
{options.message}
</div>
)}
</div>
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => onSettle(false)}>
{options.cancelLabel ?? 'Anuluj'}
</Button>
<Button
type="button"
variant={pending.destructive ? 'danger' : 'primary'}
onClick={() => settle(true)}
variant={options.destructive ? 'danger' : 'primary'}
onClick={() => onSettle(true)}
autoFocus
>
{pending.confirmLabel ?? 'Potwierdź'}
{options.confirmLabel ?? 'Potwierdź'}
</Button>
</div>
}
>
<div className="space-y-3">
{pending.destructive && (
<div className="flex items-start gap-3 rounded-md border border-alarm-500/30 bg-alarm-50 p-3">
<Icon name="alert-triangle" size={20} className="mt-0.5 shrink-0 text-alarm-600" />
<p className="text-sm leading-relaxed text-alarm-600">
Tej operacji nie da się cofnąć.
</p>
</div>
)}
{pending.message && (
<div className="text-sm leading-relaxed text-slate-600">{pending.message}</div>
)}
</div>
</Modal>
)}
</ConfirmContext.Provider>
)
}
+2 -1
View File
@@ -320,7 +320,8 @@ function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () =
message:
'Kursant nie dostanie dostępu do kursu. Zamówienie zostaje w dzienniku jako anulowane.',
confirmLabel: 'Anuluj zamówienie',
destructive: true,
// bez `destructive`: zamowienie jest dokumentem finansowym
// i zostaje w dzienniku. To zmiana stanu, nie zniszczenie.
})
if (ok) cancel.mutate()
}}
@@ -152,7 +152,8 @@ export function BrandingSection() {
title: 'Przywrócić grafikę domyślną?',
message: 'Wgrany plik zostanie usunięty, a instancja wróci do wyglądu sprzed jego dodania.',
confirmLabel: 'Przywróć domyślną',
destructive: true,
// bez `destructive`: grafike da sie wgrac z powrotem,
// a "przywroc domyslna" to cofniecie, nie zniszczenie
})
if (ok) remove.mutate(kind)
}}
+8 -8
View File
@@ -102,17 +102,17 @@ export function SpeakQueue({
if (recordingBusy) return
if (egressId) {
/*
* Bez `destructive`: plik zostaje i nic nie przepada. Nieodwracalne jest
* wylacznie to, ze nagranej chwili nie da sie powtorzyc, a to za malo na
* czerwony alarm. Ostrzezenie uzyte wszedzie traci znaczenie tam, gdzie
* strata jest prawdziwa.
*/
const ok = await confirm({
title: 'Zatrzymać nagrywanie?',
message: (
<>
Nagrywanie tego spotkania zostanie zakończone, a plik trafi do zakładki Pliki.{' '}
<strong>Tego fragmentu nie nagrasz ponownie</strong>: ponowne włączenie zacznie nowe
nagranie od bieżącej chwili.
</>
),
message:
'Plik trafi do zakładki Pliki. Ponowne włączenie zacznie nowe nagranie od bieżącej chwili.',
confirmLabel: 'Zatrzymaj nagrywanie',
destructive: true,
})
if (!ok) return
}