Add shared critical confirmation popup for destructive actions
This commit is contained in:
@@ -33,6 +33,7 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
|
|||||||
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
|
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
|
||||||
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
|
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
|
||||||
- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
|
- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
|
||||||
|
- When the owner explicitly chooses modal treatment for critical confirmations, reuse one accessible full-screen confirmation popup: blur and block the background, reveal from center, then stage text and actions.
|
||||||
- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
|
- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
|
||||||
- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete.
|
- In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete.
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,14 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri
|
|||||||
- When an existing subscription is being edited and the field is idle, use the mode-accent underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
|
- When an existing subscription is being edited and the field is idle, use the mode-accent underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode.
|
||||||
- Pause the timeout once the user enters content.
|
- Pause the timeout once the user enters content.
|
||||||
- Close and clear unfinished input on outside click or Escape.
|
- Close and clear unfinished input on outside click or Escape.
|
||||||
- Animate the trash lid independently on hover. Open an inline, non-blocking confirmation surface instead of a browser-native confirm dialog.
|
- Animate the trash lid independently on hover. Use the shared critical confirmation popup instead of a browser-native confirm dialog.
|
||||||
|
|
||||||
|
## Critical confirmation popup
|
||||||
|
|
||||||
|
- Reserve the blocking popup for explicit destructive or data-loss confirmation. It must cover the viewport, make the background inert, and use `alertdialog` with `aria-modal`.
|
||||||
|
- Fade and blur the backdrop first, resolve the popup from the center, then reveal its title, description, and actions in a short sequence.
|
||||||
|
- Put initial focus on the safe action, trap Tab within the popup, let Escape and backdrop click choose the safe action, and restore the invoking focus on close.
|
||||||
|
- Reuse the same component and motion vocabulary for every critical confirmation. Reduced motion presents the final state immediately.
|
||||||
|
|
||||||
## First-run initialization
|
## First-run initialization
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
|
|||||||
|
|
||||||
- Use one fast translucent cloud treatment for explanatory tooltips. Place the cloud above its target with strong enough contrast to survive busy content beneath it.
|
- Use one fast translucent cloud treatment for explanatory tooltips. Place the cloud above its target with strong enough contrast to survive busy content beneath it.
|
||||||
- Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control.
|
- Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control.
|
||||||
- Use inline confirmation for destructive subscription removal. Explain that VPN stops and proxy-dependent applications lose connectivity until a new subscription is configured.
|
- Use the shared full-screen critical confirmation popup for destructive actions and unsaved-data exits. Keep the centered surface flat, with hierarchy from blur, spacing, type, and staged motion rather than borders or rounded cards.
|
||||||
- Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed.
|
- Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed.
|
||||||
- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy.
|
- Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.5.0',
|
macClient: '0.5.1',
|
||||||
gatewayClient: '0.5.0',
|
gatewayClient: '0.5.1',
|
||||||
gatewayBackend: '0.5.0',
|
gatewayBackend: '0.5.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes } from '../utils/format.js';
|
||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
||||||
|
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||||||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||||
import {
|
import {
|
||||||
HARBOR_VERSIONS,
|
HARBOR_VERSIONS,
|
||||||
@@ -326,7 +327,6 @@ function LocalRulesPanel({
|
|||||||
blocked,
|
blocked,
|
||||||
dirty,
|
dirty,
|
||||||
restartPending,
|
restartPending,
|
||||||
confirmingClose,
|
|
||||||
error,
|
error,
|
||||||
operations,
|
operations,
|
||||||
panelRef,
|
panelRef,
|
||||||
@@ -334,8 +334,6 @@ function LocalRulesPanel({
|
|||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onRemoveComplete,
|
onRemoveComplete,
|
||||||
onKeepEditing,
|
|
||||||
onDiscard,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) {
|
}) {
|
||||||
@@ -373,16 +371,6 @@ function LocalRulesPanel({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
|
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
|
||||||
{confirmingClose && (
|
|
||||||
<section className="client-local-rules-discard" role="alert">
|
|
||||||
<strong>Есть несохранённые настройки</strong>
|
|
||||||
<span>Закрыть редактор и потерять изменения?</span>
|
|
||||||
<div>
|
|
||||||
<button type="button" onClick={onKeepEditing}>Остаться</button>
|
|
||||||
<button type="button" className="is-danger" onClick={onDiscard}>Закрыть без сохранения</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||||||
<span id="local-rules-list-title">Правила</span>
|
<span id="local-rules-list-title">Правила</span>
|
||||||
<div className="client-local-rules-list">
|
<div className="client-local-rules-list">
|
||||||
@@ -1014,7 +1002,9 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
<div
|
||||||
|
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
||||||
|
>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
<HarborBrand
|
<HarborBrand
|
||||||
isGateway={isGateway}
|
isGateway={isGateway}
|
||||||
@@ -1200,28 +1190,12 @@ export function ClientOverviewPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}${confirmingDelete ? ' is-confirming-delete' : ''}`}
|
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||||||
aria-hidden={subscriptionWaiting}
|
aria-hidden={subscriptionWaiting}
|
||||||
aria-disabled={gatewayDirect}
|
aria-disabled={gatewayDirect}
|
||||||
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
||||||
>
|
>
|
||||||
{confirmingDelete && <section
|
<div className="client-form-content">
|
||||||
className="client-delete-confirmation"
|
|
||||||
role="group"
|
|
||||||
aria-labelledby="delete-subscription-title"
|
|
||||||
aria-describedby="delete-subscription-description"
|
|
||||||
>
|
|
||||||
<span>Необратимое действие</span>
|
|
||||||
<h2 id="delete-subscription-title">Удалить подписку?</h2>
|
|
||||||
<p id="delete-subscription-description">
|
|
||||||
Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки.
|
|
||||||
</p>
|
|
||||||
<div className="client-delete-actions">
|
|
||||||
<button type="button" onClick={() => setConfirmingDelete(false)}>Отмена</button>
|
|
||||||
<button className="is-danger" type="button" disabled={subscriptionDeleteBlocked} onClick={forgetSubscription}>Удалить</button>
|
|
||||||
</div>
|
|
||||||
</section>}
|
|
||||||
<div className="client-form-content" inert={confirmingDelete ? true : undefined}>
|
|
||||||
<div
|
<div
|
||||||
ref={subscriptionRef}
|
ref={subscriptionRef}
|
||||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
@@ -1434,7 +1408,6 @@ export function ClientOverviewPage({
|
|||||||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||||||
dirty={localRulesDirty}
|
dirty={localRulesDirty}
|
||||||
restartPending={state?.route?.localRulesPendingRestart === true}
|
restartPending={state?.route?.localRulesPendingRestart === true}
|
||||||
confirmingClose={confirmingLocalRulesClose}
|
|
||||||
error={error}
|
error={error}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
panelRef={localRulesPanelRef}
|
panelRef={localRulesPanelRef}
|
||||||
@@ -1445,12 +1418,31 @@ export function ClientOverviewPage({
|
|||||||
onChange={changeLocalRule}
|
onChange={changeLocalRule}
|
||||||
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
||||||
onRemoveComplete={finishRemoveLocalRule}
|
onRemoveComplete={finishRemoveLocalRule}
|
||||||
onKeepEditing={() => setConfirmingLocalRulesClose(false)}
|
|
||||||
onDiscard={discardLocalRules}
|
|
||||||
onClose={requestCloseLocalRules}
|
onClose={requestCloseLocalRules}
|
||||||
onSave={saveLocalRules}
|
onSave={saveLocalRules}
|
||||||
/>}
|
/>}
|
||||||
|
<ConfirmationPopup
|
||||||
|
open={confirmingLocalRulesClose}
|
||||||
|
id="discard-local-rules"
|
||||||
|
title="Есть несохранённые настройки"
|
||||||
|
description="Закрыть редактор и потерять изменения?"
|
||||||
|
cancelLabel="Остаться"
|
||||||
|
confirmLabel="Закрыть без сохранения"
|
||||||
|
onCancel={() => setConfirmingLocalRulesClose(false)}
|
||||||
|
onConfirm={discardLocalRules}
|
||||||
|
/>
|
||||||
|
<ConfirmationPopup
|
||||||
|
open={confirmingDelete}
|
||||||
|
id="delete-subscription"
|
||||||
|
kicker="Необратимое действие"
|
||||||
|
title="Удалить подписку?"
|
||||||
|
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
|
||||||
|
cancelLabel="Отмена"
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
busy={subscriptionDeleteBlocked}
|
||||||
|
onCancel={() => setConfirmingDelete(false)}
|
||||||
|
onConfirm={forgetSubscription}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/web/components/ConfirmationPopup.jsx
Normal file
98
src/web/components/ConfirmationPopup.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export function ConfirmationPopup({
|
||||||
|
open,
|
||||||
|
id,
|
||||||
|
kicker,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
cancelLabel,
|
||||||
|
confirmLabel,
|
||||||
|
busy = false,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}) {
|
||||||
|
const overlayRef = useRef(null);
|
||||||
|
const dialogRef = useRef(null);
|
||||||
|
const cancelRef = useRef(null);
|
||||||
|
const busyRef = useRef(busy);
|
||||||
|
const onCancelRef = useRef(onCancel);
|
||||||
|
busyRef.current = busy;
|
||||||
|
onCancelRef.current = onCancel;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const previousFocus = document.activeElement;
|
||||||
|
const background = [...(overlayRef.current?.parentElement?.children || [])]
|
||||||
|
.filter((element) => !element.classList.contains('client-confirmation-popup'))
|
||||||
|
.map((element) => [element, element.inert]);
|
||||||
|
background.forEach(([element]) => { element.inert = true; });
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
const frame = requestAnimationFrame(() => cancelRef.current?.focus());
|
||||||
|
const onKeyDown = (event) => {
|
||||||
|
if (event.key === 'Escape' && !busyRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
onCancelRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
const controls = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])];
|
||||||
|
if (!controls.length) return;
|
||||||
|
const first = controls[0];
|
||||||
|
const last = controls.at(-1);
|
||||||
|
if (!dialogRef.current?.contains(document.activeElement)) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
} else if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
background.forEach(([element, inert]) => { element.inert = inert; });
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
requestAnimationFrame(() => previousFocus?.focus?.());
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className={`client-confirmation-popup${open ? ' is-open' : ''}`}
|
||||||
|
aria-hidden={!open}
|
||||||
|
inert={!open ? true : undefined}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && !busy) onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
ref={dialogRef}
|
||||||
|
className="client-confirmation-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
aria-describedby={`${id}-description`}
|
||||||
|
aria-busy={busy}
|
||||||
|
>
|
||||||
|
{kicker && <span className="client-confirmation-kicker">{kicker}</span>}
|
||||||
|
<h2 id={`${id}-title`}>{title}</h2>
|
||||||
|
<p id={`${id}-description`}>{description}</p>
|
||||||
|
<div className="client-confirmation-actions">
|
||||||
|
<button ref={cancelRef} type="button" disabled={busy} onClick={onCancel}>{cancelLabel}</button>
|
||||||
|
<button className="is-danger" type="button" disabled={busy} onClick={onConfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.querySelector('.app.client-app') || document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1132,7 +1132,6 @@ p {
|
|||||||
.client-local-rule > button:last-child:focus-visible,
|
.client-local-rule > button:last-child:focus-visible,
|
||||||
.client-local-rule-add:focus-visible,
|
.client-local-rule-add:focus-visible,
|
||||||
.client-local-rules-save:focus-visible,
|
.client-local-rules-save:focus-visible,
|
||||||
.client-local-rules-discard button:focus-visible,
|
|
||||||
.client-local-rules-actions button:focus-visible {
|
.client-local-rules-actions button:focus-visible {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
@@ -1203,39 +1202,6 @@ p {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-discard {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font-size: 9px;
|
|
||||||
line-height: 1.55;
|
|
||||||
animation: client-local-rules-notice 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-discard strong {
|
|
||||||
color: oklch(0.68 0.15 28);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-discard div {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-discard button {
|
|
||||||
padding: 4px 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--client-text);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-discard .is-danger {
|
|
||||||
color: oklch(0.68 0.15 28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-actions {
|
.client-local-rules-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -1888,90 +1854,142 @@ p {
|
|||||||
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-form.is-confirming-delete .client-form-content {
|
.client-confirmation-popup {
|
||||||
opacity: 0;
|
position: fixed;
|
||||||
filter: blur(8px);
|
inset: 0;
|
||||||
pointer-events: none;
|
z-index: 100;
|
||||||
}
|
|
||||||
|
|
||||||
.client-delete-confirmation {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
z-index: 2;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 250px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
place-items: center;
|
||||||
gap: 12px;
|
padding: 24px;
|
||||||
padding: 34px;
|
background: color-mix(in oklch, var(--client-bg) 76%, transparent);
|
||||||
border-radius: 20px;
|
-webkit-backdrop-filter: blur(0);
|
||||||
background: color-mix(in oklch, var(--client-panel) 88%, transparent);
|
backdrop-filter: blur(0);
|
||||||
box-shadow: 0 20px 54px oklch(0.08 0.015 145 / 0.12);
|
opacity: 0;
|
||||||
backdrop-filter: blur(16px);
|
visibility: hidden;
|
||||||
transform: translate(-50%, -50%);
|
pointer-events: none;
|
||||||
animation: client-delete-reveal 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
transition: opacity 260ms ease, -webkit-backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 520ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-confirmation > span {
|
.client-confirmation-popup.is-open {
|
||||||
color: oklch(0.68 0.15 28);
|
-webkit-backdrop-filter: blur(18px);
|
||||||
font-size: 10px;
|
backdrop-filter: blur(18px);
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-dialog {
|
||||||
|
width: min(430px, calc(100vw - 48px));
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 32px 34px;
|
||||||
|
background: color-mix(in oklch, var(--client-bg) 82%, transparent);
|
||||||
|
box-shadow: 0 0 86px color-mix(in oklch, var(--client-bg) 86%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(18px);
|
||||||
|
transform: scale(0.9);
|
||||||
|
transition: opacity 300ms ease, filter 520ms cubic-bezier(0.16, 1, 0.3, 1), transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-popup.is-open .client-confirmation-dialog {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: scale(1);
|
||||||
|
transition-delay: 90ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-dialog > * {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(7px);
|
||||||
|
transform: translateY(7px);
|
||||||
|
transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1), transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-popup.is-open .client-confirmation-kicker {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 170ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-popup.is-open .client-confirmation-dialog h2 {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 210ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-popup.is-open .client-confirmation-dialog p {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 270ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-popup.is-open .client-confirmation-actions {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-delay: 330ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-confirmation-kicker {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-confirmation h2 {
|
.client-confirmation-dialog h2 {
|
||||||
font-size: 22px;
|
color: oklch(0.68 0.15 28);
|
||||||
letter-spacing: -0.04em;
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-confirmation p {
|
.client-confirmation-dialog p {
|
||||||
|
max-width: 54ch;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-actions {
|
.client-confirmation-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
gap: 18px;
|
||||||
gap: 10px;
|
margin-top: 4px;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-actions button {
|
.client-confirmation-actions button {
|
||||||
padding: 10px 14px;
|
padding: 6px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 9px;
|
background: transparent;
|
||||||
background: color-mix(in oklch, var(--client-control) 74%, transparent);
|
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 220ms ease, background 220ms ease, transform 220ms ease;
|
transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-actions button:hover:not(:disabled),
|
.client-confirmation-actions button:hover:not(:disabled),
|
||||||
.client-delete-actions button:focus-visible {
|
.client-confirmation-actions button:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
background: var(--client-control);
|
filter: drop-shadow(0 0 7px currentColor);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-actions .is-danger {
|
.client-confirmation-actions .is-danger {
|
||||||
color: oklch(0.68 0.15 28);
|
color: oklch(0.68 0.15 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-delete-actions button:disabled {
|
.client-confirmation-actions button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.4;
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes client-delete-reveal {
|
|
||||||
from { opacity: 0; filter: blur(8px); transform: translate(-50%, -48%) scale(0.97); }
|
|
||||||
to { opacity: 1; filter: blur(0); transform: translate(-50%, -50%) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-form.is-waiting {
|
.client-form.is-waiting {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
filter: blur(10px);
|
filter: blur(10px);
|
||||||
@@ -2882,7 +2900,6 @@ p {
|
|||||||
.client-local-rule-add-slot > span,
|
.client-local-rule-add-slot > span,
|
||||||
.client-local-rules-save,
|
.client-local-rules-save,
|
||||||
.client-local-rules-runtime,
|
.client-local-rules-runtime,
|
||||||
.client-local-rules-discard,
|
|
||||||
.client-local-rules-actions button {
|
.client-local-rules-actions button {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
@@ -2907,8 +2924,10 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-form-content,
|
.client-form-content,
|
||||||
.client-delete-confirmation,
|
.client-confirmation-popup,
|
||||||
.client-delete-actions button,
|
.client-confirmation-dialog,
|
||||||
|
.client-confirmation-dialog > *,
|
||||||
|
.client-confirmation-actions button,
|
||||||
.client-subscription-delete .client-trash-lid {
|
.client-subscription-delete .client-trash-lid {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
const root = path.resolve(import.meta.dirname, '../..');
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
|
const popup = fs.readFileSync(path.join(root, 'src/web/components/ConfirmationPopup.jsx'), 'utf8');
|
||||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
|
||||||
test('rule editor add latency stays constant and dirty exits are guarded', () => {
|
test('rule editor add latency stays constant and dirty exits are guarded', () => {
|
||||||
@@ -14,3 +15,14 @@ test('rule editor add latency stays constant and dirty exits are guarded', () =>
|
|||||||
assert.match(component, /requestCloseLocalRules\(\)/);
|
assert.match(component, /requestCloseLocalRules\(\)/);
|
||||||
assert.match(component, /localRulesPendingRestart/);
|
assert.match(component, /localRulesPendingRestart/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('critical confirmations share one accessible blocking popup', () => {
|
||||||
|
assert.match(component, /id="discard-local-rules"/);
|
||||||
|
assert.match(component, /id="delete-subscription"/);
|
||||||
|
assert.doesNotMatch(component, /client-local-rules-discard|client-delete-confirmation/);
|
||||||
|
assert.match(popup, /role="alertdialog"/);
|
||||||
|
assert.match(popup, /aria-modal="true"/);
|
||||||
|
assert.match(popup, /querySelectorAll\(FOCUSABLE\)/);
|
||||||
|
assert.match(popup, /element\.inert = true/);
|
||||||
|
assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user